Micron Document
🎖️GitЯра🎖️

Commit 35e2abbe2626ee13222253022972bd7d90adf0d3


Parents : 2a3d04c
Author : simulationstation <32910678+simulationstation@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-13T12:10:16Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-13T12:10:16Z

fix(map): open Site Planner for the selected node (#6640)

Changes

20 files changed, 679 insertions(+), 124 deletions(-)


Diff

diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index fafb9c09c9..44f50deb71 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -333,6 +333,7 @@ dependencies {
testImplementation(kotlin("test-junit"))
testImplementation(libs.androidx.work.testing)
+ testImplementation(projects.core.testing)
testImplementation(libs.koin.test)
testRuntimeOnly(libs.junit.vintage.engine)
testImplementation(libs.kotlinx.coroutines.test)

diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt
index 21c2d4fdea..061cfe6305 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt
@@ -27,9 +27,15 @@ import org.meshtastic.core.ui.util.MapViewProvider
@Single
class FdroidMapViewProvider : MapViewProvider {
@Composable
- override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
+ override fun MapView(
+ modifier: Modifier,
+ navigateToNodeDetails: (Int) -> Unit,
+ waypointId: Int?,
+ sitePlannerNodeNum: Int?,
+ ) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
+ LaunchedEffect(sitePlannerNodeNum) { mapViewModel.setSitePlannerNodeNum(sitePlannerNodeNum) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,

diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
index 62cbcbe1a0..befbff67e0 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
@@ -158,7 +158,6 @@ import org.meshtastic.feature.map.component.DeleteWaypointDialog
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.component.MapControlsOverlay
-import org.meshtastic.feature.map.component.SitePlannerParams
import org.meshtastic.feature.map.component.WaypointInfoDialog
import org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
@@ -345,7 +344,7 @@ fun MapView(
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
val layerRenderer = remember { FdroidMapOverlayRenderer() }
var showLayersBottomSheet by remember { mutableStateOf(false) }
- var sitePlannerInitial by remember { mutableStateOf<SitePlannerParams?>(null) }
+ var sitePlannerLaunch by remember { mutableStateOf<SitePlannerLaunch?>(null) }
val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val channelSet by mapViewModel.channelSet.collectAsStateWithLifecycle()
@@ -838,7 +837,10 @@ fun MapView(
// Hands node/channel-derived params to the hosted Site Planner and imports the returned coverage.
onSitePlannerClick =
if (sitePlannerAvailable()) {
- { sitePlannerInitial = ourNodeInfo.toSitePlannerParams(channelSet) }
+ {
+ sitePlannerLaunch =
+ SitePlannerLaunch(initialParams = ourNodeInfo.toSitePlannerParams(channelSet))
+ }
} else {
null
},
@@ -890,17 +892,18 @@ fun MapView(
val sitePlannerRequest by mapViewModel.sitePlannerRequest.collectAsStateWithLifecycle()
LaunchedEffect(sitePlannerRequest) {
sitePlannerRequest?.let { node ->
- sitePlannerInitial = node.toSitePlannerParams(channelSet)
+ sitePlannerLaunch =
+ SitePlannerLaunch(initialParams = node.toSitePlannerParams(channelSet), selectedNode = node)
if (node.validPosition != null) {
map.controller.animateTo(GeoPoint(node.latitude, node.longitude))
}
- mapViewModel.consumeSitePlannerRequest()
+ mapViewModel.consumeSitePlannerRequest(node.num)
}
}
- sitePlannerInitial?.let { initial ->
+ sitePlannerLaunch?.let { launch ->
SitePlannerHost(
- initialParams = initial,
- onDismiss = { sitePlannerInitial = null },
+ initialParams = launch.initialParams,
+ onDismiss = { sitePlannerLaunch = null },
onImport = { name, geoJson, latitude, longitude ->
mapViewModel.addGeoJsonLayer(name, geoJson)
// Recenter on the estimate's transmitter so the freshly-imported coverage is on-screen.
@@ -914,8 +917,7 @@ fun MapView(
} else {
null
},
- onUseNodeLocation =
- ourNodeInfo?.takeIf { it.validPosition != null }?.let { node -> { node.latitude to node.longitude } },
+ onUseNodeLocation = launch.nodeLocation(ourNodeInfo)?.let { location -> { location } },
onUseMapCenter = { map.mapCenter.let { it.latitude to it.longitude } },
)
}

diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
index 403068e3af..79b1bf421e 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -22,7 +22,6 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.Node
@@ -107,15 +106,17 @@ class MapViewModel(
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? =
mapLayersManager.getInputStreamFromUri(layerItem)
- // Site Planner deep link from node detail: MapRoute.Map(sitePlannerNodeNum) → resolve to the node so the map can
- // open the estimate dialog pre-filled with its position. Cleared once consumed so it doesn't re-open.
- private val pendingSitePlannerNodeNum = MutableStateFlow(savedStateHandle.get<Int>("sitePlannerNodeNum"))
+ // Injected by the map provider because this SavedStateHandle is not the Navigation 3 entry's route state.
+ private val sitePlannerRequestState = SitePlannerRequestState(nodeRepository.nodeDBbyNum)
val sitePlannerRequest: StateFlow<Node?> =
- combine(pendingSitePlannerNodeNum, nodeRepository.nodeDBbyNum) { num, db -> num?.let { db[it] } }
- .stateInWhileSubscribed(initialValue = null)
+ sitePlannerRequestState.request.stateInWhileSubscribed(initialValue = null)
- fun consumeSitePlannerRequest() {
- pendingSitePlannerNodeNum.value = null
+ fun setSitePlannerNodeNum(nodeNum: Int?) {
+ sitePlannerRequestState.setNodeNum(nodeNum)
+ }
+
+ fun consumeSitePlannerRequest(nodeNum: Int) {
+ sitePlannerRequestState.consume(nodeNum)
}
}

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
index 940c4ab5a0..a6cb067110 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
@@ -27,9 +27,15 @@ import org.meshtastic.core.ui.util.MapViewProvider
@Single
class GoogleMapViewProvider : MapViewProvider {
@Composable
- override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
+ override fun MapView(
+ modifier: Modifier,
+ navigateToNodeDetails: (Int) -> Unit,
+ waypointId: Int?,
+ sitePlannerNodeNum: Int?,
+ ) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
+ LaunchedEffect(sitePlannerNodeNum) { mapViewModel.setSitePlannerNodeNum(sitePlannerNodeNum) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index 99e32c2b09..a1dec22dbe 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -175,7 +175,6 @@ import org.meshtastic.feature.map.component.DeleteWaypointDialog
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.component.MapControlsOverlay
-import org.meshtastic.feature.map.component.SitePlannerParams
import org.meshtastic.feature.map.component.WaypointInfoDialog
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.BoundingBox
@@ -569,8 +568,8 @@ fun MapView(
// --- Tile & layers state ---
var showLayersBottomSheet by remember { mutableStateOf(false) }
- // Non-null while the Site Planner estimate dialog/runner is open, holding the initial (prefilled) params.
- var sitePlannerInitial by remember { mutableStateOf<SitePlannerParams?>(null) }
+ // Non-null while the Site Planner estimate dialog/runner is open, retaining its node-location source.
+ var sitePlannerLaunch by remember { mutableStateOf<SitePlannerLaunch?>(null) }
val onAddLayerClicked = {
val intent =
@@ -910,7 +909,10 @@ fun MapView(
// Google flavor only: hands params to the hosted Site Planner and imports the returned coverage.
onSitePlannerClick =
if (sitePlannerAvailable()) {
- { sitePlannerInitial = ourNodeInfo.toSitePlannerParams(channelSet) }
+ {
+ sitePlannerLaunch =
+ SitePlannerLaunch(initialParams = ourNodeInfo.toSitePlannerParams(channelSet))
+ }
} else {
null
},
@@ -975,14 +977,15 @@ fun MapView(
val sitePlannerRequest by mapViewModel.sitePlannerRequest.collectAsStateWithLifecycle()
LaunchedEffect(sitePlannerRequest) {
sitePlannerRequest?.let { node ->
- sitePlannerInitial = node.toSitePlannerParams(channelSet)
+ sitePlannerLaunch =
+ SitePlannerLaunch(initialParams = node.toSitePlannerParams(channelSet), selectedNode = node)
if (node.validPosition != null) {
cameraPositionState.animate(CameraUpdateFactory.newLatLng(LatLng(node.latitude, node.longitude)))
}
- mapViewModel.consumeSitePlannerRequest()
+ mapViewModel.consumeSitePlannerRequest(node.num)
}
}
- sitePlannerInitial?.let { initial ->
+ sitePlannerLaunch?.let { launch ->
// Phone GPS: only when permission is already granted; otherwise the field stays manual.
val onRequestCurrentLocation: (suspend () -> Pair<Double, Double>?)? =
if (locationPermission.isGranted) {
@@ -990,12 +993,12 @@ fun MapView(
} else {
null
}
- // Our connected node's reported position: only when it has a valid fix.
+ // Route launches retain the selected node; manual map launches continue following our connected node.
val onUseNodeLocation: (() -> Pair<Double, Double>)? =
- ourNodeInfo?.takeIf { it.validPosition != null }?.let { node -> { node.latitude to node.longitude } }
+ launch.nodeLocation(ourNodeInfo)?.let { location -> { location } }
SitePlannerHost(
- initialParams = initial,
- onDismiss = { sitePlannerInitial = null },
+ initialParams = launch.initialParams,
+ onDismiss = { sitePlannerLaunch = null },
onImport = { name, geoJson, latitude, longitude ->
mapViewModel.addGeoJsonLayer(name, geoJson)
// Recenter on the estimate's transmitter so the freshly-imported coverage is on-screen.

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
index 1edf695413..5276d65eb7 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -100,15 +99,17 @@ class MapViewModel(
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
- // Site Planner deep link from node detail: MapRoute.Map(sitePlannerNodeNum) → resolve to the node so the map can
- // open the estimate dialog pre-filled with its position. Cleared once consumed so it doesn't re-open.
- private val pendingSitePlannerNodeNum = MutableStateFlow(savedStateHandle.get<Int>("sitePlannerNodeNum"))
+ // Injected by the map provider because this SavedStateHandle is not the Navigation 3 entry's route state.
+ private val sitePlannerRequestState = SitePlannerRequestState(nodeRepository.nodeDBbyNum)
val sitePlannerRequest: StateFlow<Node?> =
- combine(pendingSitePlannerNodeNum, nodeRepository.nodeDBbyNum) { num, db -> num?.let { db[it] } }
- .stateInWhileSubscribed(initialValue = null)
+ sitePlannerRequestState.request.stateInWhileSubscribed(initialValue = null)
- fun consumeSitePlannerRequest() {
- pendingSitePlannerNodeNum.value = null
+ fun setSitePlannerNodeNum(nodeNum: Int?) {
+ sitePlannerRequestState.setNodeNum(nodeNum)
+ }
+
+ fun consumeSitePlannerRequest(nodeNum: Int) {
+ sitePlannerRequestState.consume(nodeNum)
}
fun setWaypointId(id: Int?) {

diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index e92819fab4..967962577f 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -269,13 +269,14 @@ class MainActivity : AppCompatActivity() {
)
},
LocalMapMainScreenProvider provides
- { onClickNodeChip, navigateToNodeDetails, waypointId ->
+ { onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum ->
val viewModel = koinViewModel<SharedMapViewModel>()
MapScreen(
viewModel = viewModel,
onClickNodeChip = onClickNodeChip,
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
+ sitePlannerNodeNum = sitePlannerNodeNum,
)
},
content = content,

diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerParamsFactory.kt b/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerLaunch.kt
similarity index 87%
rename from androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerParamsFactory.kt
rename to androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerLaunch.kt
index f8c094c0f0..e21aeac5b2 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerParamsFactory.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerLaunch.kt
@@ -23,6 +23,13 @@ import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import kotlin.math.pow
+/** A planner session and, for Node Details routes, the node whose location shortcut it must retain. */
+internal class SitePlannerLaunch(val initialParams: SitePlannerParams, private val selectedNode: Node? = null) {
+ fun nodeLocation(connectedNode: Node?): Pair<Double, Double>? = (selectedNode ?: connectedNode)
+ ?.takeIf { it.validPosition != null }
+ ?.let { node -> node.latitude to node.longitude }
+}
+
/**
* Seed Site Planner params from a node (name + position) and, when a radio is connected, from its actual config:
* transmit frequency (from the primary channel), transmit power (dBm→W), and a receiver sensitivity derived from the

diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRequestState.kt b/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRequestState.kt
new file mode 100644
index 0000000000..c3e5329912
--- /dev/null
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRequestState.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
+import org.meshtastic.core.model.Node
+
+/** Resolves a one-shot Site Planner route request against the live node database. */
+internal class SitePlannerRequestState(nodesByNum: StateFlow<Map<Int, Node>>) {
+ private val routeRequest = MutableStateFlow<SitePlannerRouteRequest>(SitePlannerRouteRequest.None)
+
+ val request: Flow<Node?> =
+ combine(routeRequest, nodesByNum) { request, nodes ->
+ (request as? SitePlannerRouteRequest.Pending)?.nodeNum?.let { nodes[it] }
+ }
+
+ fun setNodeNum(nodeNum: Int?) {
+ routeRequest.update { current ->
+ when {
+ nodeNum == null -> SitePlannerRouteRequest.None
+ current is SitePlannerRouteRequest.Consumed && current.nodeNum == nodeNum -> current
+ else -> SitePlannerRouteRequest.Pending(nodeNum)
+ }
+ }
+ }
+
+ fun consume(nodeNum: Int) {
+ routeRequest.update { current ->
+ if (current is SitePlannerRouteRequest.Pending && current.nodeNum == nodeNum) {
+ SitePlannerRouteRequest.Consumed(nodeNum)
+ } else {
+ current
+ }
+ }
+ }
+}
+
+/** Retained in the entry-scoped ViewModel so returning to its composition cannot re-arm a consumed route argument. */
+private sealed interface SitePlannerRouteRequest {
+ data object None : SitePlannerRouteRequest
+
+ data class Pending(val nodeNum: Int) : SitePlannerRouteRequest
+
+ data class Consumed(val nodeNum: Int) : SitePlannerRouteRequest
+}

diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerLaunchTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerLaunchTest.kt
new file mode 100644
index 0000000000..2eb8b3b29f
--- /dev/null
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerLaunchTest.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map
+
+import org.meshtastic.core.model.Node
+import org.meshtastic.proto.Position
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SitePlannerLaunchTest {
+
+ @Test
+ fun `node detail launch retains selected node while manual launch follows connected node`() {
+ val connectedNode = positionedNode(num = 11, latitudeI = 10_000_000, longitudeI = 20_000_000)
+ val selectedNode = positionedNode(num = 22, latitudeI = 30_000_000, longitudeI = 40_000_000)
+ val updatedConnectedNode = positionedNode(num = 11, latitudeI = 50_000_000, longitudeI = 60_000_000)
+
+ val routeLaunch =
+ SitePlannerLaunch(
+ initialParams = selectedNode.toSitePlannerParams(channelSet = null),
+ selectedNode = selectedNode,
+ )
+ val manualLaunch = SitePlannerLaunch(initialParams = connectedNode.toSitePlannerParams(channelSet = null))
+
+ assertEquals(selectedNode.latitude to selectedNode.longitude, routeLaunch.nodeLocation(connectedNode))
+ assertEquals(
+ updatedConnectedNode.latitude to updatedConnectedNode.longitude,
+ manualLaunch.nodeLocation(updatedConnectedNode),
+ )
+ }
+
+ private fun positionedNode(num: Int, latitudeI: Int, longitudeI: Int): Node =
+ Node(num = num, position = Position(latitude_i = latitudeI, longitude_i = longitudeI))
+}

diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
index 9917c8629d..5f402a445b 100644
--- a/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
@@ -16,15 +16,23 @@
*/
package org.meshtastic.app.ui
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runComposeUiTest
+import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import kotlinx.coroutines.flow.emptyFlow
import org.junit.Test
import org.junit.runner.RunWith
+import org.meshtastic.core.navigation.MapRoute
import org.meshtastic.core.navigation.NodesRoute
+import org.meshtastic.core.ui.component.MeshtasticNavDisplay
+import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.discovery.navigation.discoveryGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
@@ -35,6 +43,7 @@ import org.meshtastic.feature.settings.navigation.settingsGraph
import org.meshtastic.feature.settings.radio.channel.channelsGraph
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
// Graph assembly only builds entry providers, so a bare Application is enough. Booting
// MeshUtilApplication here leaks its applicationScope launches into the rest of the fork.
@@ -61,4 +70,84 @@ class NavigationAssemblyTest {
}
}
}
+
+ @Test
+ fun mapRouteForwardsWaypointAndSitePlannerNode() = runComposeUiTest {
+ val route = MapRoute.Map(waypointId = 42, sitePlannerNodeNum = 8675309)
+ var receivedWaypointId: Int? = null
+ var receivedSitePlannerNodeNum: Int? = null
+
+ setContent {
+ val backStack = rememberNavBackStack(route)
+ CompositionLocalProvider(
+ LocalMapMainScreenProvider provides
+ { _, _, waypointId, sitePlannerNodeNum ->
+ SideEffect {
+ receivedWaypointId = waypointId
+ receivedSitePlannerNodeNum = sitePlannerNodeNum
+ }
+ },
+ ) {
+ MeshtasticNavDisplay(
+ backStack = backStack,
+ entryProvider = entryProvider<NavKey> { mapGraph(backStack) },
+ )
+ }
+ }
+
+ waitForIdle()
+ runOnIdle {
+ assertEquals(route.waypointId, receivedWaypointId)
+ assertEquals(route.sitePlannerNodeNum, receivedSitePlannerNodeNum)
+ }
+ }
+
+ @Test
+ fun mapRouteEffectRestartsWhenEntryReturnsFromBackStack() = runComposeUiTest {
+ val route = MapRoute.Map(sitePlannerNodeNum = 8675309)
+ lateinit var backStack: NavBackStack<NavKey>
+ var effectStarts = 0
+ var disposals = 0
+
+ setContent {
+ backStack = rememberNavBackStack(route)
+ CompositionLocalProvider(
+ LocalMapMainScreenProvider provides
+ { _, _, _, sitePlannerNodeNum ->
+ LaunchedEffect(sitePlannerNodeNum) { effectStarts += 1 }
+ DisposableEffect(Unit) { onDispose { disposals += 1 } }
+ },
+ ) {
+ MeshtasticNavDisplay(
+ backStack = backStack,
+ entryProvider =
+ entryProvider<NavKey> {
+ mapGraph(backStack)
+ // MapRoute has no list-pane metadata, so the adaptive strategy cannot pair it with the
+ // detail.
+ entry<NodesRoute.NodeDetail> {}
+ },
+ )
+ }
+ }
+
+ waitForIdle()
+ runOnIdle {
+ assertEquals(1, effectStarts)
+ assertEquals(0, disposals)
+ backStack.add(NodesRoute.NodeDetail(destNum = 11))
+ }
+
+ waitForIdle()
+ runOnIdle {
+ assertEquals(1, disposals)
+ backStack.removeLastOrNull()
+ }
+
+ waitForIdle()
+ runOnIdle {
+ assertEquals(2, effectStarts)
+ assertEquals(1, disposals)
+ }
+ }
}

diff --git a/androidApp/src/testFdroid/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt b/androidApp/src/testFdroid/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt
new file mode 100644
index 0000000000..358fcab1de
--- /dev/null
+++ b/androidApp/src/testFdroid/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt
@@ -0,0 +1,153 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.test.core.app.ApplicationProvider
+import app.cash.turbine.test
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import io.ktor.client.HttpClient
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.testing.FakeMapPrefs
+import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.testing.FakeNotificationPrefs
+import org.meshtastic.core.testing.FakeRadioConfigRepository
+import org.meshtastic.core.testing.FakeRadioController
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34], application = android.app.Application::class)
+class MapViewModelSitePlannerRequestTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+ private val nodeRepository = FakeNodeRepository()
+ private val packetRepository = mock<PacketRepository>(MockMode.autofill)
+ private val mapPrefs = FakeMapPrefs()
+ private val firstNode = Node(num = 11)
+ private val secondNode = Node(num = 22)
+ private lateinit var httpClient: HttpClient
+ private lateinit var mapLayersManager: MapLayersManager
+ private lateinit var viewModel: MapViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ every { packetRepository.getWaypoints() } returns flowOf(emptyList())
+ httpClient = HttpClient()
+ mapLayersManager =
+ MapLayersManager(
+ application = ApplicationProvider.getApplicationContext(),
+ dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher),
+ httpClient = httpClient,
+ mapPrefs = mapPrefs,
+ )
+
+ nodeRepository.setNodes(listOf(firstNode, secondNode))
+ viewModel =
+ MapViewModel(
+ mapPrefs = mapPrefs,
+ packetRepository = packetRepository,
+ nodeRepository = nodeRepository,
+ radioController = FakeRadioController(),
+ radioConfigRepository = FakeRadioConfigRepository(),
+ notificationPrefs = FakeNotificationPrefs(),
+ mapLayersManager = mapLayersManager,
+ savedStateHandle = SavedStateHandle(),
+ )
+ }
+
+ @After
+ fun tearDown() {
+ httpClient.close()
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `consumed site planner request is not rearmed by entry recomposition`() = runTest(testDispatcher) {
+ viewModel.sitePlannerRequest.test {
+ assertNull(awaitItem())
+
+ viewModel.setSitePlannerNodeNum(firstNode.num)
+ assertEquals(firstNode, awaitItem())
+
+ viewModel.consumeSitePlannerRequest(firstNode.num)
+ assertNull(awaitItem())
+
+ viewModel.setSitePlannerNodeNum(firstNode.num)
+ runCurrent()
+ expectNoEvents()
+ assertNull(viewModel.sitePlannerRequest.value)
+ }
+ }
+
+ @Test
+ fun `new route request replaces pending request and stale consumption cannot clear it`() = runTest(testDispatcher) {
+ viewModel.sitePlannerRequest.test {
+ assertNull(awaitItem())
+
+ viewModel.setSitePlannerNodeNum(firstNode.num)
+ assertEquals(firstNode, awaitItem())
+ viewModel.setSitePlannerNodeNum(secondNode.num)
+ assertEquals(secondNode, awaitItem())
+
+ viewModel.consumeSitePlannerRequest(firstNode.num)
+ expectNoEvents()
+ assertEquals(secondNode, viewModel.sitePlannerRequest.value)
+ }
+ }
+
+ @Test
+ fun `pending request follows the live node and stops after consumption`() = runTest(testDispatcher) {
+ viewModel.sitePlannerRequest.test {
+ assertNull(awaitItem())
+
+ viewModel.setSitePlannerNodeNum(firstNode.num)
+ assertEquals(firstNode, awaitItem())
+
+ val updatedNode = firstNode.copy(notes = "updated while pending")
+ nodeRepository.setNodes(listOf(updatedNode, secondNode))
+ assertEquals(updatedNode, awaitItem())
+
+ viewModel.consumeSitePlannerRequest(updatedNode.num)
+ assertNull(awaitItem())
+
+ nodeRepository.setNodes(listOf(firstNode, secondNode))
+ expectNoEvents()
+ }
+ }
+}

diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt
new file mode 100644
index 0000000000..1e3c4776cc
--- /dev/null
+++ b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt
@@ -0,0 +1,172 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.map
+
+import android.app.Application
+import androidx.lifecycle.SavedStateHandle
+import androidx.test.core.app.ApplicationProvider
+import app.cash.turbine.test
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import io.ktor.client.HttpClient
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.app.map.model.CustomTileProviderConfig
+import org.meshtastic.app.map.prefs.map.GoogleCameraPosition
+import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
+import org.meshtastic.app.map.repository.CustomTileProviderRepository
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.testing.FakeMapPrefs
+import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.testing.FakeNotificationPrefs
+import org.meshtastic.core.testing.FakeRadioConfigRepository
+import org.meshtastic.core.testing.FakeRadioController
+import org.meshtastic.core.testing.FakeUiPrefs
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34], application = Application::class)
+class MapViewModelSitePlannerRequestTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+ private val nodeRepository = FakeNodeRepository()
+ private val packetRepository = mock<PacketRepository>(MockMode.autofill)
+ private val mapPrefs = FakeMapPrefs()
+ private val googleMapsPrefs = mock<GoogleMapsPrefs>(MockMode.autofill)
+ private val customTileProviderRepository = mock<CustomTileProviderRepository>(MockMode.autofill)
+ private val firstNode = Node(num = 11)
+ private val secondNode = Node(num = 22)
+ private lateinit var httpClient: HttpClient
+ private lateinit var mapLayersManager: MapLayersManager
+ private lateinit var viewModel: MapViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ every { packetRepository.getWaypoints() } returns flowOf(emptyList())
+ httpClient = HttpClient()
+ mapLayersManager =
+ MapLayersManager(
+ application = ApplicationProvider.getApplicationContext(),
+ dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher),
+ httpClient = httpClient,
+ mapPrefs = mapPrefs,
+ )
+ every { googleMapsPrefs.cameraPosition } returns flowOf<GoogleCameraPosition?>(null)
+ every { googleMapsPrefs.selectedCustomTileUrl } returns MutableStateFlow(null)
+ every { googleMapsPrefs.selectedGoogleMapType } returns MutableStateFlow(null)
+ every { customTileProviderRepository.getCustomTileProviders() } returns
+ flowOf<List<CustomTileProviderConfig>>(emptyList())
+
+ nodeRepository.setNodes(listOf(firstNode, secondNode))
+ viewModel =
+ MapViewModel(
+ application = ApplicationProvider.getApplicationContext(),
+ dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher),
+ mapLayersManager = mapLayersManager,
+ mapPrefs = mapPrefs,
+ googleMapsPrefs = googleMapsPrefs,
+ nodeRepository = nodeRepository,
+ packetRepository = packetRepository,
+ radioConfigRepository = FakeRadioConfigRepository(),
+ radioController = FakeRadioController(),
+ customTileProviderRepository = customTileProviderRepository,
+ uiPrefs = FakeUiPrefs(),
+ notificationPrefs = FakeNotificationPrefs(),
+ savedStateHandle = SavedStateHandle(),
+ )
+ }
+
+ @After
+ fun tearDown() {
+ httpClient.close()
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `consumed site planner request is not rearmed by entry recomposition`() = runTest(testDispatcher) {
+ viewModel.sitePlannerRequest.test {
+ assertNull(awaitItem())
+
+ viewModel.setSitePlannerNodeNum(firstNode.num)
+ assertEquals(firstNode, awaitItem())
+
+ viewModel.consumeSitePlannerRequest(firstNode.num)
+ assertNull(awaitItem())
+
+ viewModel.setSitePlannerNodeNum(firstNode.num)
+ runCurrent()
+ expectNoEvents()
+ assertNull(viewModel.sitePlannerRequest.value)
+ }
+ }
+
+ @Test
+ fun `new route request replaces pending request and stale consumption cannot clear it`() = runTest(testDispatcher) {
+ viewModel.sitePlannerRequest.test {
+ assertNull(awaitItem())
+
+ viewModel.setSitePlannerNodeNum(firstNode.num)
+ assertEquals(firstNode, awaitItem())
+ viewModel.setSitePlannerNodeNum(secondNode.num)
+ assertEquals(secondNode, awaitItem())
+
+ viewModel.consumeSitePlannerRequest(firstNode.num)
+ expectNoEvents()
+ assertEquals(secondNode, viewModel.sitePlannerRequest.value)
+ }
+ }
+
+ @Test
+ fun `pending request follows the live node and stops after consumption`() = runTest(testDispatcher) {
+ viewModel.sitePlannerRequest.test {
+ assertNull(awaitItem())
+
+ viewModel.setSitePlannerNodeNum(firstNode.num)
+ assertEquals(firstNode, awaitItem())
+
+ val updatedNode = firstNode.copy(notes = "updated while pending")
+ nodeRepository.setNodes(listOf(updatedNode, secondNode))
+ assertEquals(updatedNode, awaitItem())
+
+ viewModel.consumeSitePlannerRequest(updatedNode.num)
+ assertNull(awaitItem())
+
+ nodeRepository.setNodes(listOf(firstNode, secondNode))
+ expectNoEvents()
+ }
+ }
+}

diff --git a/core/ui/detekt-baseline.xml b/core/ui/detekt-baseline.xml
index d4b850442d..7f4221e110 100644
--- a/core/ui/detekt-baseline.xml
+++ b/core/ui/detekt-baseline.xml
@@ -19,7 +19,7 @@
<ID>CompositionLocalAllowlist:LocalBarcodeScannerProvider.kt:val LocalBarcodeScannerProvider = compositionLocalOf&lt;@Composable (onResult: (String?) -> Unit) -> BarcodeScanner> { { object : BarcodeScanner { override fun startScan() { // Default NO-OP } } } }</ID>
<ID>CompositionLocalAllowlist:LocalBarcodeScannerProvider.kt:val LocalBarcodeScannerSupported = compositionLocalOf { false }</ID>
<ID>CompositionLocalAllowlist:LocalInlineMapProvider.kt:val LocalInlineMapProvider = compositionLocalOf&lt;@Composable (node: Node, modifier: Modifier) -> Unit> { { _, _ -> } }</ID>
- <ID>CompositionLocalAllowlist:LocalMapMainScreenProvider.kt:/** * Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it * falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalMapMainScreenProvider = compositionLocalOf&lt; @Composable (onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) -> Unit, > { { _, _, _ -> PlaceholderScreen("Map") } }</ID>
+ <ID>CompositionLocalAllowlist:LocalMapMainScreenProvider.kt:/** * Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it * falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalMapMainScreenProvider = compositionLocalOf&lt; @Composable ( onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?, sitePlannerNodeNum: Int?, ) -> Unit, > { { _, _, _, _ -> PlaceholderScreen("Map") } }</ID>
<ID>CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerProvider = compositionLocalOf&lt;@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } }</ID>
<ID>CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerSupported = compositionLocalOf { false }</ID>
<ID>CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcWriterProvider = compositionLocalOf&lt;@Composable (url: String, onResult: (Boolean) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _, _ -> } }</ID>
@@ -29,7 +29,6 @@
<ID>CompositionLocalAllowlist:LocalTracerouteMapProvider.kt:/** * Provides an embeddable traceroute map composable that renders node markers and forward/return offset polylines for a * traceroute result. Unlike [LocalMapViewProvider], this does **not** include a Scaffold, AppBar, waypoints, location * tracking, custom tiles, or any main-map features — it is designed to be embedded inside `TracerouteMapScreen`'s * scaffold. * * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. * * Parameters: * - `tracerouteOverlay`: The overlay with forward/return route node nums. * - `tracerouteNodePositions`: Map of node num to position snapshots for the route nodes. * - `onMappableCountChanged`: Callback with (shown, total) node counts. * - `modifier`: Compose modifier for the map. */ @Suppress("Wrapping") val LocalTracerouteMapProvider = compositionLocalOf&lt; @Composable ( tracerouteOverlay: TracerouteOverlay?, tracerouteNodePositions: Map&lt;Int, Position>, onMappableCountChanged: (Int, Int) -> Unit, modifier: Modifier, ) -> Unit, > { { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } }</ID>
<ID>CompositionLocalAllowlist:LocalTracerouteMapScreenProvider.kt:/** * Provides the platform-specific Traceroute Map Screen. On Desktop or JVM targets where native maps aren't available * yet, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalTracerouteMapScreenProvider = compositionLocalOf&lt;@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> { { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } }</ID>
<ID>CompositionLocalAllowlist:MapViewProvider.kt:val LocalMapViewProvider = compositionLocalOf&lt;MapViewProvider?> { null }</ID>
- <ID>ContentSlotReused:AdaptiveTwoPane.kt:second: @Composable ColumnScope.() -> Unit</ID>
<ID>FunctionTypeModifierSpacing:Theme.kt:@Composable()</ID>
<ID>LambdaParameterInRestartableEffect:EmojiPickerDialog.kt:onCategoryChanged: (Int) -> Unit</ID>
<ID>LambdaParameterInRestartableEffect:PlatformUtils.kt:check: () -> Boolean</ID>
@@ -42,18 +41,12 @@
<ID>MagicNumber:EditListPreference.kt:67890</ID>
<ID>MagicNumber:LazyColumnDragAndDropDemo.kt:50</ID>
<ID>MatchingDeclarationName:LocalTracerouteMapOverlayInsetsProvider.kt:TracerouteMapOverlayInsets</ID>
- <ID>ModifierMissing:AdaptiveTwoPane.kt:@Composable fun AdaptiveTwoPane</ID>
<ID>ModifierMissing:ChannelItem.kt:@Composable fun ChannelItem</ID>
<ID>ModifierMissing:ChannelSelection.kt:@Composable fun ChannelSelection</ID>
- <ID>ModifierMissing:EmojiPickerDialog.kt:@Composable fun EmojiPickerDialog</ID>
<ID>ModifierMissing:IndoorAirQuality.kt:@Suppress("LongMethod", "UnusedPrivateProperty") @Composable fun IndoorAirQuality</ID>
- <ID>ModifierMissing:LoraSignalIndicator.kt:@Composable fun LoraSignalIndicator</ID>
- <ID>ModifierMissing:LoraSignalIndicator.kt:@Composable fun SnrAndRssi</ID>
<ID>ModifierMissing:PlaceholderScreen.kt:@Composable fun PlaceholderScreen</ID>
<ID>ModifierMissing:PreferenceDivider.kt:@Composable fun PreferenceDivider</ID>
<ID>ModifierMissing:SecurityIcon.kt:@Composable fun SecurityIcon</ID>
- <ID>ModifierMissing:SharedContactDialog.kt:@Composable fun SharedContactDialog</ID>
- <ID>ModifierMissing:SlidingSelector.kt:@Composable fun OptionLabel</ID>
<ID>ModifierMissing:TracerouteAlertHandler.kt:@Composable fun TracerouteAlertHandler</ID>
<ID>ModifierNaming:MeshtasticAppShell.kt:hostModifier: Modifier = Modifier</ID>
<ID>ModifierNotUsedAtRoot:TextDividerPreference.kt:modifier = modifier.fillMaxWidth().padding(all = 16.dp)</ID>
@@ -78,16 +71,12 @@
<ID>ParameterNaming:EmojiPickerDialog.kt:onCategoryChanged: (Int) -> Unit</ID>
<ID>ParameterNaming:EmojiPickerDialog.kt:onCategorySelected: (Int) -> Unit</ID>
<ID>ParameterNaming:EmojiPickerDialog.kt:onEmojiSelected: (String) -> Unit</ID>
- <ID>ParameterNaming:PlatformUtils.kt:onDenied: () -> Unit</ID>
- <ID>ParameterNaming:PlatformUtils.kt:onDenied: () -> Unit = {}</ID>
- <ID>ParameterNaming:PlatformUtils.kt:onGranted: () -> Unit</ID>
<ID>ParameterNaming:PlatformUtils.kt:onUriReceived: (CommonUri) -> Unit</ID>
<ID>ParameterNaming:PlatformUtils.kt:onUriReceived: (CommonUri?) -> Unit</ID>
<ID>ParameterNaming:PlatformUtils.kt:onUriReceived: (org.meshtastic.core.common.util.CommonUri) -> Unit</ID>
<ID>ParameterNaming:PositionPrecisionPreference.kt:onValueChanged: (Int) -> Unit</ID>
<ID>ParameterNaming:PreferenceFooter.kt:onNegativeClicked: () -> Unit = {}</ID>
<ID>ParameterNaming:PreferenceFooter.kt:onPositiveClicked: () -> Unit = {}</ID>
- <ID>ParameterNaming:SlidingSelector.kt:onOptionSelected: (T) -> Unit</ID>
<ID>PreviewPublic:AlertPreviews.kt:@Preview(showBackground = true, name = "Composable Content Alert") @Composable fun PreviewComposableAlert</ID>
<ID>PreviewPublic:AlertPreviews.kt:@Preview(showBackground = true, name = "HTML Alert") @Composable fun PreviewHtmlAlert</ID>
<ID>PreviewPublic:AlertPreviews.kt:@Preview(showBackground = true, name = "Icon and Text Alert") @Composable fun PreviewIconAlert</ID>
@@ -130,6 +119,5 @@
<ID>ViewModelForwarding:MeshtasticCommonAppSetup.kt:FirmwareVersionCheck(viewModel = uiViewModel)</ID>
<ID>ViewModelForwarding:MeshtasticCommonAppSetup.kt:SharedDialogs(uiViewModel = uiViewModel)</ID>
<ID>ViewModelForwarding:MeshtasticCommonAppSetup.kt:TracerouteAlertHandler(uiViewModel = uiViewModel, onNavigateToMap = onNavigateToTracerouteMap)</ID>
- <ID>ViewModelForwarding:MeshtasticNavigationSuite.kt:NavigationIconContent( destination = destination, isSelected = isSelected, connectionState = connectionState, unreadMessageCount = unreadMessageCount, selectedDevice = selectedDevice, uiViewModel = uiViewModel, )</ID>
</CurrentIssues>
</SmellBaseline>

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt
index 70ed07a2b0..70d02f4426 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt
@@ -27,7 +27,12 @@ import org.meshtastic.core.ui.component.PlaceholderScreen
@Suppress("Wrapping")
val LocalMapMainScreenProvider =
compositionLocalOf<
- @Composable (onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) -> Unit,
+ @Composable (
+ onClickNodeChip: (Int) -> Unit,
+ navigateToNodeDetails: (Int) -> Unit,
+ waypointId: Int?,
+ sitePlannerNodeNum: Int?,
+ ) -> Unit,
> {
- { _, _, _ -> PlaceholderScreen("Map") }
+ { _, _, _, _ -> PlaceholderScreen("Map") }
}

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt
index 10d975f3d4..a911da8557 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt
@@ -25,7 +25,13 @@ import androidx.compose.ui.Modifier
* implementations (Google Maps vs OSMDroid). Platform implementations create their own ViewModel via Koin.
*/
interface MapViewProvider {
- @Composable fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int? = null)
+ @Composable
+ fun MapView(
+ modifier: Modifier,
+ navigateToNodeDetails: (Int) -> Unit,
+ waypointId: Int? = null,
+ sitePlannerNodeNum: Int? = null,
+ )
}
val LocalMapViewProvider = compositionLocalOf<MapViewProvider?> { null }

diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
index ccbfe6b5d0..950d271633 100644
--- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
@@ -36,6 +36,7 @@ fun MapScreen(
modifier: Modifier = Modifier,
viewModel: SharedMapViewModel,
waypointId: Int? = null,
+ sitePlannerNodeNum: Int? = null,
) {
val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
@@ -59,6 +60,7 @@ fun MapScreen(
modifier = Modifier.fillMaxSize().padding(paddingValues),
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
+ sitePlannerNodeNum = sitePlannerNodeNum,
)
}
}

diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
index 00df4cac3b..54b049834d 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
@@ -29,6 +29,7 @@ fun EntryProviderScope<NavKey>.mapGraph(backStack: NavBackStack<NavKey>) {
{ id -> backStack.add(NodesRoute.NodeDetail(id)) }, // onClickNodeChip
{ id -> backStack.add(NodesRoute.NodeDetail(id)) }, // navigateToNodeDetails
args.waypointId,
+ args.sitePlannerNodeNum,
)
}
}

Served by rngit 1.5.0 - Generated in 0.23s